refactor: decouple JSON extraction rejection from axum#348
refactor: decouple JSON extraction rejection from axum#348
Conversation
Introduce `JsonExtractionRejection` as an s2-api-owned rejection type, replacing direct usage of `axum::extract::rejection::JsonRejection` in the public API. This gives us control over the error boundary without coupling consumers to axum's internal rejection types. The `FromRequest` impl still delegates to `axum::Json` internally — the only change is that the rejection is converted at the boundary via `From<axum::JsonRejection>`. Zero behavioral change, zero performance impact on the success path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Greptile SummaryThis PR introduces
Confidence Score: 5/5
Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[HTTP Request] --> B[axum::Json FromRequest]
B -->|Success| C[Json value]
B -->|Failure| D[axum::JsonRejection]
D -->|JsonDataError| E1[JsonExtractionRejection::DataError\nstatus: 422, message: Cow::Owned]
D -->|JsonSyntaxError| E2[JsonExtractionRejection::SyntaxError\nstatus: 400, message: Cow::Owned]
D -->|MissingJsonContentType| E3[JsonExtractionRejection::MissingContentType\nstatus: 415, message: Cow::Borrowed static]
D -->|Other| E4[JsonExtractionRejection::Other\nstatus: from rej, message: Cow::Owned]
E1 & E2 & E3 & E4 --> F{Caller}
F -->|AppendRequestRejection| G[IntoResponse\ndirect status from rejection]
F -->|ServiceError| H[to_response\nstatus from ErrorCode::BadJson]
G --> I[HTTP Response]
H --> I
Prompt To Fix All With AIThis is a comment left during a code review.
Path: api/src/data.rs
Line: 162-168
Comment:
**Redundant inner `match` on `Cow` variant**
The two arms of the inner `match message { ... }` body are syntactically identical. While the arms are correct (binding different inner types, `&'static str` vs `String`, both of which implement `IntoResponse`), the pattern reads as though the two cases are handled differently.
Since `SyntaxError`, `DataError`, and `Other` are always constructed from dynamic error messages (via `.into()` on a `String`), the `Cow::Borrowed` arm is unreachable in practice today. Simplifying to `message.into_owned()` makes the intent clearer and removes the dead branch:
```suggestion
Self::SyntaxError { message, .. }
| Self::DataError { message, .. }
| Self::Other { message, .. } => (status, message.into_owned()).into_response(),
```
`into_owned()` is a no-op for `Cow::Owned` and adds one alloc for `Cow::Borrowed`, which is acceptable in an error path.
How can I resolve this? If you propose a fix, please make it concise.Reviews (3): Last reviewed commit: "perf: zero-alloc MissingContentType and ..." | Re-trigger Greptile |
- Move `classify_json_error` into `#[cfg(test)]` module to avoid leaking axum-coupled helper through the public API (greptile review feedback) - Install rustls CryptoProvider in `dns_error_message_is_clear` test to fix pre-existing panic with rustls 0.23+ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
All variants now store `{ status, message }` uniformly. The message is
captured once in the `From<JsonRejection>` bridge and reused directly in
`body_text()`, `Display`, and `IntoResponse` — no re-allocation.
`IntoResponse` moves the String instead of cloning. `body_text()` does
one clone (matches axum's existing behavior where consumers call it).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Static messages (MissingContentType) are now zero-alloc. Dynamic messages from axum's body_text() are stored as Cow::Owned. Display writes directly from the Cow — no intermediate allocation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
into_response() now destructures and moves the Cow, calling into_owned() which is a no-op for Cow::Owned (just unwraps). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- MissingContentType is now a unit variant with a static message const — zero heap allocation on construction, display, and response rendering - body_text() returns &str instead of String — no clone needed - IntoResponse matches on Cow: Borrowed passes &str directly, Owned moves the String — no intermediate allocation in either case Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| match self { | ||
| Self::SyntaxError { message, .. } | ||
| | Self::DataError { message, .. } | ||
| | Self::Other { message, .. } => match message { | ||
| Cow::Borrowed(s) => (status, s).into_response(), | ||
| Cow::Owned(s) => (status, s).into_response(), | ||
| }, |
There was a problem hiding this comment.
Redundant inner
match on Cow variant
The two arms of the inner match message { ... } body are syntactically identical. While the arms are correct (binding different inner types, &'static str vs String, both of which implement IntoResponse), the pattern reads as though the two cases are handled differently.
Since SyntaxError, DataError, and Other are always constructed from dynamic error messages (via .into() on a String), the Cow::Borrowed arm is unreachable in practice today. Simplifying to message.into_owned() makes the intent clearer and removes the dead branch:
| match self { | |
| Self::SyntaxError { message, .. } | |
| | Self::DataError { message, .. } | |
| | Self::Other { message, .. } => match message { | |
| Cow::Borrowed(s) => (status, s).into_response(), | |
| Cow::Owned(s) => (status, s).into_response(), | |
| }, | |
| Self::SyntaxError { message, .. } | |
| | Self::DataError { message, .. } | |
| | Self::Other { message, .. } => (status, message.into_owned()).into_response(), |
into_owned() is a no-op for Cow::Owned and adds one alloc for Cow::Borrowed, which is acceptable in an error path.
Prompt To Fix With AI
This is a comment left during a code review.
Path: api/src/data.rs
Line: 162-168
Comment:
**Redundant inner `match` on `Cow` variant**
The two arms of the inner `match message { ... }` body are syntactically identical. While the arms are correct (binding different inner types, `&'static str` vs `String`, both of which implement `IntoResponse`), the pattern reads as though the two cases are handled differently.
Since `SyntaxError`, `DataError`, and `Other` are always constructed from dynamic error messages (via `.into()` on a `String`), the `Cow::Borrowed` arm is unreachable in practice today. Simplifying to `message.into_owned()` makes the intent clearer and removes the dead branch:
```suggestion
Self::SyntaxError { message, .. }
| Self::DataError { message, .. }
| Self::Other { message, .. } => (status, message.into_owned()).into_response(),
```
`into_owned()` is a no-op for `Cow::Owned` and adds one alloc for `Cow::Borrowed`, which is acceptable in an error path.
How can I resolve this? If you propose a fix, please make it concise.
Summary
Introduces
JsonExtractionRejection— an s2-api-owned rejection type replacing direct usage ofaxum::extract::rejection::JsonRejectionin the public API. Consumers import froms2_api::data::extractinstead ofaxum.Motivation
Consumers of s2-api with the
axumfeature currently need to importaxum::extract::rejection::JsonRejectiondirectly. This couples them to axum's internal rejection types, making it harder to evolve the extraction layer independently.With this change, consumers only depend on
s2_api::data::extract::JsonExtractionRejection, which exposes the samebody_text()andstatus()interface.Design
{ status: StatusCode, message: Cow<'static, str> }— no special casesCow<'static, str>allows zero-alloc construction for static messages (e.g.MissingContentType) while carrying dynamic error strings from the deserializer asCow::OwnedFrom<axum::JsonRejection>bridge converts at the boundary —FromRequeststill delegates toaxum::JsoninternallyIntoResponsemoves theCowand callsinto_owned()— no-op forCow::Owned, one small alloc forCow::Borrowed#[non_exhaustive]for forward compatibilityrej.status()(not hardcoded) to handle future axum rejection variantsAllocation impact
Cowreduces the worst case (static-literal construction) but there are still extra clones in the downstream response rendering path (e.g.body_text()returnsString, andlite'sServiceError::to_responseclones when buildingErrorInfo). That's acceptable for this decoupling PR to keep scope tight — it isn't allocation-neutral yet, but the success path is unchanged and the error path costs are honest.Cow::Borrowed)body_text())body_text().into()→Cow::Owned)IntoResponserenderingCow::Owned(into_ownedunwraps), 1 small alloc forCow::Borrowedbody_text()caller (e.g. lite)into_owned()→String)Display/ loggingCowdirectly)Changes
api/src/data.rs:JsonExtractionRejectionenum withCow<'static, str>messages,From<axum::JsonRejection>bridge,body_text()/status()/IntoResponse/Display/Errorimpls.FromRequestandOptionalFromRequestforJson<T>now returnJsonExtractionRejection. Error classification test verifies 400/422 status code parity with axum.api/src/v1/stream/extract.rs:AppendRequestRejectionwrapsJsonExtractionRejectioninstead ofaxum::JsonRejection. Importscrate::data::Jsoninstead ofaxum::Json.lite/src/handlers/v1/error.rs: Import swap fromaxum::extract::rejection::JsonRejectiontos2_api::data::extract::JsonExtractionRejection.sdk/src/api.rs: Fix pre-existingdns_error_message_is_cleartest failure — install rustlsCryptoProvider(required since rustls 0.23+).Test plan
cargo check --workspacepassesjust test— all pass (including previously broken DNS test)json_error_classificationtest: verifies status codes for syntax errors (400) and data errors (422) across 7 input casesvalid_json_parses_successfullytest: verifies happy path🤖 Generated with Claude Code